本节代码对应 GitHub 分支: chapter10
首先开发 redux 数据层。
# axios 请求准备
在 api/request.js 中加入:
export const getHotKeyWordsRequest = () => {
return axiosInstance.get (`/search/hot`);
};
export const getSuggestListRequest = query => {
return axiosInstance.get (`/search/suggest?keywords=${query}`);
};
export const getResultSongsListRequest = query => {
return axiosInstance.get (`/search?keywords=${query}`);
};
# redux 层开发
# 1. 声明初始化 state
//reducer.js
import * as actionTypes from './constants';
import { fromJS } from 'immutable';
const defaultState = fromJS ({
hotList: [], // 热门关键词列表
suggestList: [],// 列表,包括歌单和歌手
songsList: [],// 歌曲列表
enterLoading: false
})
# 2. 定义 constants
//constants.js
export const SET_HOT_KEYWRODS = "search/SET_HOT_KEYWRODS";
export const SET_SUGGEST_LIST = 'search/SET_SUGGEST_LIST';
export const SET_RESULT_SONGS_LIST = 'search/SET_RESULT_SONGS_LIST';
export const SET_ENTER_LOADING = 'search/SET_ENTER_LOADING';
# 3. 定义 reducer 函数
export default (state = defaultState, action) => {
switch (action.type) {
case actionTypes.SET_HOT_KEYWRODS:
return state.set ('hotList', action.data);
case actionTypes.SET_SUGGEST_LIST:
return state.set ('suggestList', action.data);
case actionTypes.SET_RESULT_SONGS_LIST:
return state.set ('songsList', action.data);
case actionTypes.SET_ENTER_LOADING:
return state.set ('enterLoading', action.data);
default:
return state;
}
}
# 4. 编写具体的 action
逻辑都非常简单,直接放出代码:
//actionCreators.js
import { SET_HOT_KEYWRODS, SET_SUGGEST_LIST, SET_RESULT_SONGS_LIST, SET_ENTER_LOADING } from './constants';
import { fromJS } from 'immutable';
import { getHotKeyWordsRequest, getSuggestListRequest, getResultSongsListRequest } from './../../../api/request';
const changeHotKeyWords = (data) => ({
type: SET_HOT_KEYWRODS,
data: fromJS (data)
});
const changeSuggestList = (data) => ({
type: SET_SUGGEST_LIST,
data: fromJS (data)
});
const changeResultSongs = (data) => ({
type: SET_RESULT_SONGS_LIST,
data: fromJS (data)
});
export const changeEnterLoading = (data) => ({
type: SET_ENTER_LOADING,
data
});
export const getHotKeyWords = () => {
return dispatch => {
getHotKeyWordsRequest ().then (data => {
// 拿到关键词列表
let list = data.result.hots;
dispatch (changeHotKeyWords (list));
})
}
};
export const getSuggestList = (query) => {
return dispatch => {
getSuggestListRequest (query).then (data => {
if (!data) return;
let res = data.result || [];
dispatch (changeSuggestList (res));
})
getResultSongsListRequest (query).then (data => {
if (!data) return;
let res = data.result.songs || [];
dispatch (changeResultSongs (res));
dispatch (changeEnterLoading (false));// 关闭 loading
})
}
};
# 5. 将相关变量导出
//index.js
import reducer from './reducer'
import * as actionCreators from './actionCreators'
import * as constants from './constants'
export { reducer, actionCreators, constants };
# 组件连接 Redux
首先,需要将 Search 下的 reducer 注册到全局 store,在 src 目录下的 store/reducer.js 中。(注意,这个操作非常重要,当时因为这个问题调整了很久,后来打开 redux-devtools 中才猛然发现。)
import { combineReducers } from "redux-immutable";
import { reducer as recommendReducer } from "../application/Recommend/store/index";
import { reducer as singersReducer } from "../application/Singers/store/index";
import { reducer as rankReducer } from "../application/Rank/store/index";
import { reducer as albumReducer } from "../application/Album/store/index";
import { reducer as singerInfoReducer } from "../application/Singer/store/index";
import { reducer as playerReducer } from "../application/Player/store/index";
import { reducer as searchReducer } from "../application/Search/store/index";
export default combineReducers ({
recommend: recommendReducer,
singers: singersReducer,
rank: rankReducer,
album: albumReducer,
singerInfo: singerInfoReducer,
player: playerReducer,
search: searchReducer,
});
现在在 Search/index.js 中,准备连接 Redux。 增加代码:
import { connect } from 'react-redux';
// 组件代码
// 映射 Redux 全局的 state 到组件的 props 上
const mapStateToProps = (state) => ({
hotList: state.getIn (['search', 'hotList']),
enterLoading: state.getIn (['search', 'enterLoading']),
suggestList: state.getIn (['search', 'suggestList']),
songsCount: state.getIn (['player', 'playList']).size,
songsList: state.getIn (['search', 'songsList'])
});
// 映射 dispatch 到 props 上
const mapDispatchToProps = (dispatch) => {
return {
getHotKeyWordsDispatch () {
dispatch (getHotKeyWords ());
},
changeEnterLoadingDispatch (data) {
dispatch (changeEnterLoading (data))
},
getSuggestListDispatch (data) {
dispatch (getSuggestList (data));
},
}
};
// 将 ui 组件包装成容器组件
export default connect (mapStateToProps, mapDispatchToProps)(React.memo (Search));
# 组件对接真实数据
首先在组件中取出 redux 中的数据:
import { getHotKeyWords, changeEnterLoading, getSuggestList } from './store/actionCreators';
import { connect } from 'react-redux';
import { Container, ShortcutWrapper, HotKey } from './style';
import Scroll from '../../baseUI/scroll';
const {
hotList,
enterLoading,
suggestList: immutableSuggestList,
songsCount,
songsList: immutableSongsList
} = props;
const suggestList = immutableSuggestList.toJS ();
const songsList = immutableSongsList.toJS ();
const {
getHotKeyWordsDispatch,
changeEnterLoadingDispatch,
getSuggestListDispatch,
getSongDetailDispatch
} = props;
我们接下来要做三件事情:
- 当搜索框为空,展示热门搜索列表
- 当搜索框有内容时,发送 Ajax 请求,显示搜索结果
- 点击搜索结果,分别进入到不同的详情页中
第一步,当搜索框为空时:
//Search 组件内
const renderHotKey = () => {
let list = hotList ? hotList.toJS (): [];
return (
<ul>
{
list.map (item => {
return (
<li className="item" key={item.first} onClick={() => setQuery (item.first)}>
<span>{item.first}</span>
</li>
)
})
}
</ul>
)
};
//Container 组件中添加
<ShortcutWrapper show={!query}>
<Scroll>
<div>
<HotKey>
<h1 className="title"> 热门搜索 </h1>
{renderHotKey ()}
</HotKey>
</div>
</Scroll>
</ShortcutWrapper>
对应的 style.js:
export const ShortcutWrapper = styled.div`
position: absolute;
top: 40px;
bottom: 0;
width: 100%;
display: ${props => props.show ? "":"none"};
`
export const HotKey = styled.div`
margin: 0 20px 20px 20px;
.title {
padding-top: 35px;
margin-bottom: 20px;
font-size: ${style ["font-size-m"]};
color: ${style ["font-color-desc-v2"]};
}
.item {
display: inline-block;
padding: 5px 10px;
margin: 0 20px 10px 0;
border-radius: 6px;
background: ${style ["highlight-background-color"]};
font-size: ${style ["font-size-m"]};
color: ${style ["font-color-desc"]};
}
`
当组件初次渲染时,我们发送 Ajax 请求拿到热门列表。
useEffect (() => {
setShow (true);
// 用了 redux 缓存,不再赘述
if (!hotList.size)
getHotKeyWordsDispatch ();
}, []);
现在就能成功地看到热门标签了,而且点击标记,搜索框的内容也能跟着改变。
第二步,搜索框有内容时:
在 handleQuery 中加入下面的逻辑。
const handleQuery = (q) => {
//...
if (!q) return;
changeEnterLoadingDispatch (true);
getSuggestListDispatch (q);
}
然后分别渲染歌单、歌手和单曲列表。
// 顺便引入 Loading
import Loading from './../../baseUI/loading/index';
const renderSingers = () => {};
const renderAlbum = () => {};
const renderSongs = () => {};
{/* 紧接在热门列表后面 */}
{/* 下面为搜索结果 */}
<ShortcutWrapper show={query}>
<Scroll onScorll={forceCheck}>
<div>
{renderSingers ()}
{renderAlbum ()}
{renderSongs ()}
</div>
</Scroll>
</ShortcutWrapper>
{ enterLoading? <Loading></Loading> : null }
对于歌单而言:
// 注意引入相应组件
import LazyLoad, {forceCheck} from 'react-lazyload';
import { List, ListItem } from './style';
const renderAlbum = () => {
let albums = suggestList.playlists;
if (!albums || !albums.length) return;
return (
<List>
<h1 className="title"> 相关歌单 </h1>
{
albums.map ((item, index) => {
return (
<ListItem key={item.accountId+""+index}>
<div className="img_wrapper">
<LazyLoad placeholder={<img width="100%" height="100%" src={require ('./music.png')} alt="music"/>}>
<img src={item.coverImgUrl} width="100%" height="100%" alt="music"/>
</LazyLoad>
</div>
<span className="name"> 歌单: {item.name}</span>
</ListItem>
)
})
}
</List>
)
};
style.js 中的 List 和 ListItem 如下:
export const List = styled.div`
display: flex;
margin: auto;
flex-direction: column;
overflow: hidden;
.title {
margin:10px 0 10px 10px;
color: ${style ["font-color-desc"]};
font-size: ${style ["font-size-s"]};
}
`;
export const ListItem = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: row;
margin: 0 5px;
padding: 5px 0;
align-items: center;
border-bottom: 1px solid ${style ["border-color"]};
.img_wrapper {
margin-right: 20px;
img {
border-radius: 3px;
width: 50px;
height: 50px;
}
}
.name {
font-size: ${style ["font-size-m"]};
color: ${style ["font-color-desc"]};
font-weight: 500;
}
`;
这里进入的是一个全新的路由。但是我们可以复用 Album 组件,在 routes/index.js 增加:
//...
// 增加 album 路由,用来显示歌单
{
path: "/album/:id",
exact: true,
key: "album",
component: Album
},
{
path: "/search",
exact: true,
key: "search",
component: Search
}
//...
对于歌手而言:
const renderSingers = () => {
let singers = suggestList.artists;
if (!singers || !singers.length) return;
return (
<List>
<h1 className="title"> 相关歌手 </h1>
{
singers.map ((item, index) => {
return (
<ListItem key={item.accountId+""+index}>
<div className="img_wrapper">
<LazyLoad placeholder={<img width="100%" height="100%" src={require ('./singer.png')} alt="singer"/>}>
<img src={item.picUrl} width="100%" height="100%" alt="music"/>
</LazyLoad>
</div>
<span className="name"> 歌手: {item.name}</span>
</ListItem>
)
})
}
</List>
)
};
对于单曲列表:
// 引入代码
import { SongItem } from './style';
import { getName } from '../../api/utils';
const renderSongs = () => {
return (
<SongItem style={{paddingLeft: "20px"}}>
{
songsList.map (item => {
return (
<li key={item.id}>
<div className="info">
<span>{item.name}</span>
<span>
{ getName (item.artists) } - { item.album.name }
</span>
</div>
</li>
)
})
}
</SongItem>
)
SongItem 对应的样式代码:
export const SongItem = styled.ul`
>li {
display: flex;
height: 60px;
align-items: center;
.index {
width: 60px;
height: 60px;
line-height: 60px;
text-align: center;
}
.info {
box-sizing: border-box;
flex: 1;
display: flex;
height: 100%;
padding: 5px 0;
flex-direction: column;
justify-content: space-around;
border-bottom: 1px solid ${style ["border-color"]};
>span:first-child {
color: ${style ["font-color-desc"]};
}
>span:last-child {
font-size: ${style ["font-size-s"]};
color: #bba8a8;
}
}
}
`
对应的 music.png 和 singer.png 占位图片已经放在仓库中,有需要可以去仓库拷贝一份。
这三者的逻辑虽然有点复杂,但是难度并不大。这里就不过多的拆解,大家将代码过一遍,能够理解每一步做的什么事情即可。
第三步,点击结果,进入到各自的详情页。
在 renderSingers 方法中:
<ListItem key={item.accountId+""+index} onClick={() => props.history.push (`/singers/${item.id}`)}>
在 renderAlbum 方法中:
<ListItem key={item.accountId+""+index} onClick={() => props.history.push (`/album/${item.id}`)}>
在 renderSongs 方法中:
<li key={item.id} onClick={(e) => selectItem (e, item.id)}>
而 selectItem 定义如下:
const selectItem = (e, id) => {
}
重点来了!现在歌单和歌手详情页都能正确跳转,后面的逻辑当然能走的通了,剩下的就是如何处理单曲的问题。我们希望点击单曲后能够直接播放,那么首先需要 将选中的单曲加入到播放列表中。顺便提一句,网易云给到的搜索单曲的接口中数据并不完整,需要我们拿到 id 再重新获取具体的单曲数据,然后再添加到播放列表中。
axios 请求部分:
export const getSongDetailRequest = id => {
return axiosInstance.get (`/song/detail?ids=${id}`);
};
关于歌曲的逻辑属于播放器部分,因此我们转到 Player/store/actionCreators.js 中来编写:
import { getSongDetailRequest } from '../../../api/request';
import { INSERT_SONG } from './constants';
export const insertSong = (data) => ({
type: INSERT_SONG,
data
});
export const getSongDetail = (id) => {
return (dispatch) => {
getSongDetailRequest (id).then (data => {
let song = data.songs [0];
dispatch (insertSong ( song));
})
}
}
同目录 constants.js 中添加:
export const INSERT_SONG = 'player/INSERT_SONG';
然后再 reducer 编写具体的 insert 逻辑:
export default (state = defaultState, action) => {
switch (action.type) {
//...
case actionTypes.INSERT_SONG:
return handleInsertSong (state, action.data);
default:
return state;
}
}
handleInsertSong 的逻辑还是比较复杂的,我们单独拎出来拆解:
const handleInsertSong = (state, song) => {
const playList = JSON.parse (JSON.stringify (state.get ('playList').toJS ()));
const sequenceList = JSON.parse (JSON.stringify (state.get ('sequencePlayList').toJS ()));
let currentIndex = state.get ('currentIndex');
// 看看有没有同款
let fpIndex = findIndex (song, playList);
// 如果是当前歌曲直接不处理
if (fpIndex === currentIndex && currentIndex !== -1) return state;
currentIndex++;
// 把歌放进去,放到当前播放曲目的下一个位置
playList.splice (currentIndex, 0, song);
// 如果列表中已经存在要添加的歌,暂且称它 oldSong
if (fpIndex > -1) {
// 如果 oldSong 的索引在目前播放歌曲的索引小,那么删除它,同时当前 index 要减一
if (currentIndex > fpIndex) {
playList.splice (fpIndex, 1);
currentIndex--;
} else {
// 否则直接删掉 oldSong
playList.splice (fpIndex+1, 1);
}
}
// 同理,处理 sequenceList
let sequenceIndex = findIndex (playList [currentIndex], sequenceList) + 1;
let fsIndex = findIndex (song, sequenceList);
// 插入歌曲
sequenceList.splice (sequenceIndex, 0, song);
if (fsIndex > -1) {
// 跟上面类似的逻辑。如果在前面就删掉,index--; 如果在后面就直接删除
if (sequenceIndex > fsIndex) {
sequenceList.splice (fsIndex, 1);
sequenceIndex--;
} else {
sequenceList.splice (fsIndex + 1, 1);
}
}
return state.merge ({
'playList': fromJS (playList),
'sequencePlayList': fromJS (sequenceList),
'currentIndex': fromJS (currentIndex),
});
}
现在插入的逻辑可以在 Search 组件中运用了。
const mapDispatchToProps = (dispatch) => {
return {
//...
getSongDetailDispatch (id) {
dispatch (getSongDetail (id));
}
}
};
在组件中:
const selectItem = (e, id) => {
getSongDetailDispatch (id);
}
现在点击单曲后,歌曲就能正常播放啦!
由于没有加上音符组件,因此这里不会有音符坠落的动画,加上去也非常简单。
import MusicalNote from '../../baseUI/music-note';
import { useRef } from 'react';
// 组件内部
const musicNoteRef = useRef ();
// 返回的 JSX
// Container 标签中加入
<MusicalNote ref={musicNoteRef}></MusicalNote>
然后在 selectItem 方法中加入一行代码就 OK:
const selectItem = (e, id) => {
getSongDetailDispatch (id);
musicNoteRef.current.startAnimation ({x:e.nativeEvent.clientX, y:e.nativeEvent.clientY});
}
当然,还剩下一个小小的 bug,事实上 Container 还是会遮盖住 miniPlayer。
之前专门修复了不少这样的 bug, 现在贴上代码:
//Search/index.js
<Container play={songsCount}>
style.js 中:
export const Container = styled.div`
//...
bottom: ${props => props.play > 0 ? "60px": 0};
//...
`
搜索模块现在就开发完毕了。总体来说,还是非常复杂的一个组件。希望大家好好消化一下,对自己是一个很好的锻炼。